/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.catalina.authenticator;

import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.util.StringTokenizer;

import javax.servlet.http.HttpServletResponse;

import org.apache.catalina.Realm;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.deploy.LoginConfig;
import org.apache.catalina.util.MD5Encoder;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;

/**
 * An <b>Authenticator</b> and <b>Valve</b> implementation of HTTP DIGEST
 * Authentication (see RFC 2069).
 * 
 * @author Craig R. McClanahan
 * @author Remy Maucherat
 * @version $Revision: 467222 $ $Date: 2006-10-24 11:17:11 +0800 (星期二, 24 十月
 *          2006) $
 */

public class DigestAuthenticator extends AuthenticatorBase {
	private static Log log = LogFactory.getLog(DigestAuthenticator.class);

	// -------------------------------------------------------------- Constants

	/**
	 * The MD5 helper object for this class.
	 */
	protected static final MD5Encoder md5Encoder = new MD5Encoder();

	/**
	 * Descriptive information about this implementation.
	 */
	protected static final String info = "org.apache.catalina.authenticator.DigestAuthenticator/1.0";

	// ----------------------------------------------------------- Constructors

	public DigestAuthenticator() {
		super();
		try {
			if (md5Helper == null)
				md5Helper = MessageDigest.getInstance("MD5");
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			throw new IllegalStateException();
		}
	}

	// ----------------------------------------------------- Instance Variables

	/**
	 * MD5 message digest provider.
	 */
	protected static MessageDigest md5Helper;

	/**
	 * Private key.
	 */
	protected String key = "Catalina";

	// ------------------------------------------------------------- Properties

	/**
	 * Return descriptive information about this Valve implementation.
	 */
	public String getInfo() {

		return (info);

	}

	// --------------------------------------------------------- Public Methods

	/**
	 * Authenticate the user making this request, based on the specified login
	 * configuration. Return <code>true</code> if any specified constraint has
	 * been satisfied, or <code>false</code> if we have created a response
	 * challenge already.
	 * 
	 * @param request
	 *            Request we are processing
	 * @param response
	 *            Response we are creating
	 * @param config
	 *            Login configuration describing how authentication should be
	 *            performed
	 * 
	 * @exception IOException
	 *                if an input/output error occurs
	 */
	public boolean authenticate(Request request, Response response,
			LoginConfig config) throws IOException {

		// Have we already authenticated someone?
		Principal principal = request.getUserPrincipal();
		// String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE);
		if (principal != null) {
			if (log.isDebugEnabled())
				log
						.debug("Already authenticated '" + principal.getName()
								+ "'");
			// Associate the session with any existing SSO session in order
			// to get coordinated session invalidation at logout
			String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE);
			if (ssoId != null)
				associate(ssoId, request.getSessionInternal(true));
			return (true);
		}

		// NOTE: We don't try to reauthenticate using any existing SSO session,
		// because that will only work if the original authentication was
		// BASIC or FORM, which are less secure than the DIGEST auth-type
		// specified for this webapp
		//
		// Uncomment below to allow previous FORM or BASIC authentications
		// to authenticate users for this webapp
		// TODO make this a configurable attribute (in SingleSignOn??)
		/*
		 * // Is there an SSO session against which we can try to
		 * reauthenticate? if (ssoId != null) { if (log.isDebugEnabled())
		 * log.debug("SSO Id " + ssoId + " set; attempting " +
		 * "reauthentication"); // Try to reauthenticate using data cached by
		 * SSO. If this fails, // either the original SSO logon was of DIGEST or
		 * SSL (which // we can't reauthenticate ourselves because there is no //
		 * cached username and password), or the realm denied // the user's
		 * reauthentication for some reason. // In either case we have to prompt
		 * the user for a logon if (reauthenticateFromSSO(ssoId, request))
		 * return true; }
		 */

		// Validate any credentials already included with this request
		String authorization = request.getHeader("authorization");
		if (authorization != null) {
			principal = findPrincipal(request, authorization, context
					.getRealm());
			if (principal != null) {
				String username = parseUsername(authorization);
				register(request, response, principal, Constants.DIGEST_METHOD,
						username, null);
				return (true);
			}
		}

		// Send an "unauthorized" response and an appropriate challenge

		// Next, generate a nOnce token (that is a token which is supposed
		// to be unique).
		String nOnce = generateNOnce(request);

		setAuthenticateHeader(request, response, config, nOnce);
		response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
		// hres.flushBuffer();
		return (false);

	}

	// ------------------------------------------------------ Protected Methods

	/**
	 * Parse the specified authorization credentials, and return the associated
	 * Principal that these credentials authenticate (if any) from the specified
	 * Realm. If there is no such Principal, return <code>null</code>.
	 * 
	 * @param request
	 *            HTTP servlet request
	 * @param authorization
	 *            Authorization credentials from this request
	 * @param realm
	 *            Realm used to authenticate Principals
	 */
	protected static Principal findPrincipal(Request request,
			String authorization, Realm realm) {

		// System.out.println("Authorization token : " + authorization);
		// Validate the authorization credentials format
		if (authorization == null)
			return (null);
		if (!authorization.startsWith("Digest "))
			return (null);
		authorization = authorization.substring(7).trim();

		// Bugzilla 37132:
		// http://issues.apache.org/bugzilla/show_bug.cgi?id=37132
		String[] tokens = authorization.split(",(?=(?:[^\"]*\"[^\"]*\")+$)");

		String userName = null;
		String realmName = null;
		String nOnce = null;
		String nc = null;
		String cnonce = null;
		String qop = null;
		String uri = null;
		String response = null;
		String method = request.getMethod();

		for (int i = 0; i < tokens.length; i++) {
			String currentToken = tokens[i];
			if (currentToken.length() == 0)
				continue;

			int equalSign = currentToken.indexOf('=');
			if (equalSign < 0)
				return null;
			String currentTokenName = currentToken.substring(0, equalSign)
					.trim();
			String currentTokenValue = currentToken.substring(equalSign + 1)
					.trim();
			if ("username".equals(currentTokenName))
				userName = removeQuotes(currentTokenValue);
			if ("realm".equals(currentTokenName))
				realmName = removeQuotes(currentTokenValue, true);
			if ("nonce".equals(currentTokenName))
				nOnce = removeQuotes(currentTokenValue);
			if ("nc".equals(currentTokenName))
				nc = removeQuotes(currentTokenValue);
			if ("cnonce".equals(currentTokenName))
				cnonce = removeQuotes(currentTokenValue);
			if ("qop".equals(currentTokenName))
				qop = removeQuotes(currentTokenValue);
			if ("uri".equals(currentTokenName))
				uri = removeQuotes(currentTokenValue);
			if ("response".equals(currentTokenName))
				response = removeQuotes(currentTokenValue);
		}

		if ((userName == null) || (realmName == null) || (nOnce == null)
				|| (uri == null) || (response == null))
			return null;

		// Second MD5 digest used to calculate the digest :
		// MD5(Method + ":" + uri)
		String a2 = method + ":" + uri;
		// System.out.println("A2:" + a2);

		byte[] buffer = null;
		synchronized (md5Helper) {
			buffer = md5Helper.digest(a2.getBytes());
		}
		String md5a2 = md5Encoder.encode(buffer);

		return (realm.authenticate(userName, response, nOnce, nc, cnonce, qop,
				realmName, md5a2));

	}

	/**
	 * Parse the username from the specified authorization string. If none can
	 * be identified, return <code>null</code>
	 * 
	 * @param authorization
	 *            Authorization string to be parsed
	 */
	protected String parseUsername(String authorization) {

		// System.out.println("Authorization token : " + authorization);
		// Validate the authorization credentials format
		if (authorization == null)
			return (null);
		if (!authorization.startsWith("Digest "))
			return (null);
		authorization = authorization.substring(7).trim();

		StringTokenizer commaTokenizer = new StringTokenizer(authorization, ",");

		while (commaTokenizer.hasMoreTokens()) {
			String currentToken = commaTokenizer.nextToken();
			int equalSign = currentToken.indexOf('=');
			if (equalSign < 0)
				return null;
			String currentTokenName = currentToken.substring(0, equalSign)
					.trim();
			String currentTokenValue = currentToken.substring(equalSign + 1)
					.trim();
			if ("username".equals(currentTokenName))
				return (removeQuotes(currentTokenValue));
		}

		return (null);

	}

	/**
	 * Removes the quotes on a string. RFC2617 states quotes are optional for
	 * all parameters except realm.
	 */
	protected static String removeQuotes(String quotedString,
			boolean quotesRequired) {
		// support both quoted and non-quoted
		if (quotedString.length() > 0 && quotedString.charAt(0) != '"'
				&& !quotesRequired) {
			return quotedString;
		} else if (quotedString.length() > 2) {
			return quotedString.substring(1, quotedString.length() - 1);
		} else {
			return new String();
		}
	}

	/**
	 * Removes the quotes on a string.
	 */
	protected static String removeQuotes(String quotedString) {
		return removeQuotes(quotedString, false);
	}

	/**
	 * Generate a unique token. The token is generated according to the
	 * following pattern. NOnceToken = Base64 ( MD5 ( client-IP ":" time-stamp
	 * ":" private-key ) ).
	 * 
	 * @param request
	 *            HTTP Servlet request
	 */
	protected String generateNOnce(Request request) {

		long currentTime = System.currentTimeMillis();

		String nOnceValue = request.getRemoteAddr() + ":" + currentTime + ":"
				+ key;

		byte[] buffer = null;
		synchronized (md5Helper) {
			buffer = md5Helper.digest(nOnceValue.getBytes());
		}
		nOnceValue = md5Encoder.encode(buffer);

		return nOnceValue;
	}

	/**
	 * Generates the WWW-Authenticate header.
	 * <p>
	 * The header MUST follow this template :
	 * 
	 * <pre>
	 *      WWW-Authenticate    = &quot;WWW-Authenticate&quot; &quot;:&quot; &quot;Digest&quot;
	 *                            digest-challenge
	 * 
	 *      digest-challenge    = 1#( realm | [ domain ] | nOnce |
	 *                  [ digest-opaque ] |[ stale ] | [ algorithm ] )
	 * 
	 *      realm               = &quot;realm&quot; &quot;=&quot; realm-value
	 *      realm-value         = quoted-string
	 *      domain              = &quot;domain&quot; &quot;=&quot; &lt;&quot;&gt; 1#URI &lt;&quot;&gt;
	 *      nonce               = &quot;nonce&quot; &quot;=&quot; nonce-value
	 *      nonce-value         = quoted-string
	 *      opaque              = &quot;opaque&quot; &quot;=&quot; quoted-string
	 *      stale               = &quot;stale&quot; &quot;=&quot; ( &quot;true&quot; | &quot;false&quot; )
	 *      algorithm           = &quot;algorithm&quot; &quot;=&quot; ( &quot;MD5&quot; | token )
	 * </pre>
	 * 
	 * @param request
	 *            HTTP Servlet request
	 * @param response
	 *            HTTP Servlet response
	 * @param config
	 *            Login configuration describing how authentication should be
	 *            performed
	 * @param nOnce
	 *            nonce token
	 */
	protected void setAuthenticateHeader(Request request, Response response,
			LoginConfig config, String nOnce) {

		// Get the realm name
		String realmName = config.getRealmName();
		if (realmName == null)
			realmName = request.getServerName() + ":" + request.getServerPort();

		byte[] buffer = null;
		synchronized (md5Helper) {
			buffer = md5Helper.digest(nOnce.getBytes());
		}

		String authenticateHeader = "Digest realm=\"" + realmName + "\", "
				+ "qop=\"auth\", nonce=\"" + nOnce + "\", " + "opaque=\""
				+ md5Encoder.encode(buffer) + "\"";
		response.setHeader("WWW-Authenticate", authenticateHeader);

	}

}
